11.1 单元测试

单元测试(unit test)除用来测试逻辑算法是否符合预期外,还承担着监控代码质量的责任。任何时候都可用简单的命令来验证全部功能,找出未完成任务(验收)和任何因修改而造成的错误。它与性能测试、代码覆盖率等一起保障了代码总是在可控范围内,这远比形式化的人工检查要有用得多。

单元测试并非要取代人工代码审查(code review),实际上它也无法切入到代码实现层面。但可通过测试结果为审查提供筛选依据,避免因烦琐导致代码审查沦为形式主义。单元测试可自动化进行,能持之以恒。但测试毕竟只是手段,而非目的,所以如何合理安排测试就需要开发人员因地制宜。

可将测试、版本管理工具,以及自动发布(nightly build)整合。编写脚本将测试失败结果与代码提交日志相匹配,最终生成报告发往指定邮箱。

很多人认为单元测试代码不好写,不知道怎么测试。如果非技术原因,那么需要考虑结构设计是否合理,因为可测试性也是代码质量的一个体现。

在我看来,写单元测试本身就是对即将要实现的算法做复核预演。因为无论什么算法都需要输入条件,返回预期结果。这些,加上平时写在main里面的临时代码,本就是一个完整的单元测试用例,无非换个地方存放而已。

testing

工具链和标准库自带单元测试框架,这让测试工作变得相对容易。

  • 测试代码须放在当前包以“_test.go”结尾的文件中。
  • 测试函数以Test为名称前缀。
  • 测试命令(go test)忽略以“_”或“.”开头的测试文件。
  • 正常编译操作(go build/install)会忽略测试文件。

main_test.go

package main

import( “testing” )

func add(x,y int)int{ return x+y }

func TestAdd(t*testing.T) { if add(1,2) !=3{ t.FailNow() } }

输出:

$go test-v # 要测试当前包及所有子包,可用go test./…

===RUN TestAdd ---PASS:TestAdd(0.00s) PASS ok test 0.006s

标准库testing提供了专用类型T来控制测试结果和行为。

方法 说明 相关 ------------------+-----------------------------+----------------- Fail 失败:继续执行当前测试函数 FailNow 失败:立即终止执行当前测试函数 Failed SkipNow 跳过:停止执行当前测试函数 Skip,Skipf,Skipped Log 输出错误信息。仅失败或 -v时输出 Logf Parallel 与有同样设置的测试函数并行执行 Error Fail+Log Errorf Fatal FailNow+Log Fatalf

使用Parallel可有效利用多核并行优势,缩短测试时间。

func TestA(ttesting.T) { t.Parallel() time.Sleep(time.Second2) }

func TestB(t*testing.T) { if os.Args[len(os.Args)-1] == “b” { t.Parallel() }

time.Sleep(time.Second*2) }

输出:

$go test-v

---PASS:TestB(2.00s) ---PASS:TestA(2.00s) PASS ok test 4.014s

$go test-v-args”b”

---PASS:TestA(2.00s) ---PASS:TestB(2.00s) PASS ok test 2.009s

从测试总耗时可以看到并行执行的结果只有2秒。

只有一个测试函数调用Parallel方法并没有效果,且go test执行参数parallel必须大于1。

常用测试参数:

参数 说明 示例 ------------+-----------------------------------+------------------------ -args 命令行参数 -v 输出详细信息 -parallel 并发执行,默认值为GOMAXPROCS -parallel 2 -run 指定测试函数,正则表达式 -run”Add” -timeout 全部测试累计时间超时将引发panic,默认值为10ms -timeout 1m30s
-count 重复测试次数,默认值为1

对于测试是否应该和目标放在同一目录,一直有不同看法。某些人认为应该另建一个专门的包用来存放单元测试,且只测试目标公开接口。好处是,当目标内部发生变化时,无须同步维护测试代码。每个人对于测试都有不同理解,就像覆盖率是否要做到90%以上,也是见仁见智。

table driven

单元测试代码一样要写得简洁优雅,要做到这点并不容易。好在多数时候,我们可用一种类似数据表的模式来批量输入条件并依次比对结果。

func add(x,y int)int{ return x+y }

func TestAdd(t*testing.T) { var tests= []struct{ x int y int expect int }{ {1,1,2}, {2,2,4}, {3,2,5}, }

for_,tt:=range tests{ actual:=add(tt.x,tt.y) if actual!=tt.expect{ t.Errorf(“add(%d, %d):expect%d,actual%d”,tt.x,tt.y,tt.expect,actual) } } }

这种方式将测试数据和测试逻辑分离,更便于维护。另外,使用Error是为了让整个表全部完成测试,以便知道具体是哪组条件出现问题。

test main

某些时候,须为测试用例提供初始化和清理操作,但testing并没有setup/teardown机制。解决方法是自定义一个名为TestMain的函数,go test会改为执行该函数,而不再是具体的测试用例。

func TestMain(m*testing.M) { //setup code:=m.Run() // 调用测试用例函数 //teardown os.Exit(code) // 注意:os.Exit不会执行defer }

M.Run会调用具体的测试用例,但麻烦的是不能为每个测试文件写一个TestMain。

multiple definitions of TestMain

要实现用例组合套件(suite),须借助MainStart自行构建M对象。通过与命令行参数相配合,即可实现不同测试组合。

func TestMain(m*testing.M) { match:=func(pat,str string) (bool,error) { //pat: 命令行参数 -run提供的过滤条件 return true,nil //str:InternalTest.Name }

tests:= []testing.InternalTest{ // 用例列表,可排序 {“b”,TestB}, {“a”,TestA}, }

benchmarks:= []testing.InternalBenchmark{} examples:= []testing.InternalExample{}

m=testing.MainStart(match,tests,benchmarks,examples) os.Exit(m.Run()) }

example

例代码最大的用途不是测试,而是导入到GoDoc等工具生成的帮助文档中。它通过比对输出(stdout)结果和内部output注释是否一致来判断是否成功。

func ExampleAdd() { fmt.Println(add(1,2)) fmt.Println(add(2,2))

//Output: 
//3
//4

}

输出:

$go test-v

===RUN ExampleAdd ---PASS:ExampleAdd(0.00s) PASS

ok test 0.006s

如果没有output注释,该示例函数就不会被执行。另外,不能使用内置函数print/println,因为它们输出到stderr。